小程序视角下同构方案思考
文末福利:淘系 618 实践小册
随着各家闭环生态的建设发展,小程序已经成为了各个业务不可缺少的一部分。各家为了提升自己在应用内生态上的可控性,都给出了自己的小程序方案,如:支付宝小程序、微信小程序、京东小程序等。对于业务研发团队来讲,如何实现多平台适配(H5 + 各端小程序)一直是摆在面前的一道难题。
NO.1
现有同构方案
其实,小程序之间的互转相对比较简单。得益于微信小程序的先行,各家在设计小程序 DSL 和 API 时,通常会尽量靠拢微信小程序,以降低学习成本和转换成本。
现有同构方案大致可以分为两类:静态编译 & 动态解析。
静态编译
静态编译的方案很多,基于 Vue DSL 的有 Chameleon(https://cml.js.org/) 、MPVue(http://mpvue.com/) 等,基于 React JSX 的有 Taro(https://nervjs.github.io/taro/)、Rax(https://rax.js.org/) 等。
由于小程序的 DSL 本身就有参考 Vue 的设计;再加上其本身就是静态语言,没有运行时,所以类 Vue DSL 的框架,在转译方案上的设计实现心智成本会低很多。而 JSX 则不然:JSX 本质就是 JavaScript 的高阶语法,对于众多 React 开发者来讲,这种完全的 JavaScript 环境为我们提供了巨大的便利。但问题是,JSX 直接运行在 JS 运行时上,对于许多表达式,完全无法在静态编译阶段求值。
举一些例子:
// DEMO 1
function DemoA({list}) {
return (
<div>
{list.map(item => <div key={item.id}>{item.content}</div>)}
</div>
)
}
// DEMO 2
function DemoB({visible}) {
if (!visible) {
return null
}
return <div>cool</div>
}
// DEMO 3
function SomeFunctionalRender({children, ...props}) {
return typeof children === 'function' ? children(props) : null
}
function DemoC() {
return (
<SomeFunctionalRender>
{props => <div>{props.content}</div>}
</SomeFunctionalRender>
)
}
这三个 DEMO 最终的 DOM(VDOM)结果都需要在运行时获知。如果说 DEMO 1 和 DEMO 2 还能通过 AST 解析强行转换成小程序 DSL(a:for / a:if),那 DEMO 3 就是小程序 DSL 这种静态 DSL 的噩梦。可能有些读者会觉得 DEMO 3 的写法很「抬杠」,事实上这种语法在 React 世界非常常见,如著名的动画库 react-spring(https://www.react-spring.io/) 。
那么,Taro 和 Rax 是如何解这些问题的呢?
做减法。通过对 JSX 进行「裁剪」,限制 JSX 的可用语法,以尽可能对小程序语法兼容。
先说我们比较熟悉的 Rax:Rax 在 JSX 语法的基础上,扩展了一套 JSX+(https://rax.js.org/docs/guide/jsxplus) 语法,让开发者使用声明式的方式撰写条件渲染、循环、slot 等代码,以替代 Array.property.map,if / else 等。这样的好处是,可以限制开发者在 children 中撰写复杂的 JavaScript 表达式,同时又不至于让 JSX 丧失诸如条件渲染等渲染能力。
而 Taro 的路子相对更「友好」一些:Taro 没有去扩展 JSX 语法,而是通过 AST 分析,尽可能将代码中的 Array.property.map、if / else ,三目表达式,枚举渲染等转换成了小程序可识别的静态 DSL 。这种转换的心智成本固然是非常高的,而且有些语法(如 DEMO 3)是没有办法用静态 DSL 实现的,但是能够尽可能的还原最「原汁原味」的 JSX 开发体验。
动态解析
可能是由于 JSX 的接受度逐年提升,很多新生的小程序同构框架都在拥抱 React 。近两年,在使用 JSX 撰写 H5 + 小程序同构代码上又有了新的思路 — 动态解析:既然 JSX 高度依赖 JavaScript 运行时,那么我们是否可以给它创造一个运行时。典型的方案代表:Remax(https://remaxjs.org/) 和 Frad(https://github.com/yisar/fard) 。
回顾一下 React 的渲染路径:
React 默认提供了 State to Virtual DOM to DOM 的方法。重点在后者:Virtual DOM to DOM。React 使用 React Reconciler 完成了 Virtual DOM to DOM 的工作。React Reconciler 允许开发者自定义更新 DOM(也可能是别的视图层)的方式,详见 react-reconciler(https://github.com/facebook/react/tree/master/packages/react-reconciler) 。React Native 也是通过实现自己的 reconciler 实现视图更新的。
既然 State to Virtual DOM 的方式 React 提供了,Virtual DOM to DOM 的方式我们又可以自定义,那么,也许我们可以找到在小程序上通过 Virtual DOM 表达生成小程序 DOM 的方法。
小程序提供了 template 组件(https://opendocs.alipay.com/mini/framework/axml-template),用来帮助开发者动态化的调用小程序组件。通过 template 组件,便有机会解析 Virtual DOM,动态生成小程序 DOM 。此处不再赘述,感兴趣的读者可以阅读以下 Remax 团队的文章 Remax - 使用 React 开发小程序(https://zhuanlan.zhihu.com/p/101909025) 。
NO.2
更进一步:性能
动态解析的方案完全还原了 React 的体验,因为它提供了完整的 JavaScript 运行时。通过 React Reconciler,小开发者将自己从视图层上完全解放了出来,心智停留在了 Virtual DOM 上,不再需要关心最终产物是 Web DOM 还是小程序 DOM。
但是,动态性带来的代价也是很清晰的:性能损耗。没有编译器性能调优(本来也没有),没有 Dead Code Elimination,没有剪枝,对于 JavaScript 来讲,就是实打实的,每一次 render ,每一个节点都要计算。再加上小程序 template 渲染本身的开销,叠加在一起只性能敏感的场景下(低端机 / 长列表 / 多图)会尤其捉襟见肘。
于是,开发者又有了新的问题:如何在保证灵活性的同时,尽可能提升渲染性能?
NO.3
业务封装
在 Remax 的方案中,Remax 直接使用了小程序组件作为基础 DOM Element ,这也就意味着,每一个业务组件都要从最原子的 view / text 等进行渲染。然而,对于业务来讲,许多业务组件是固定且可复用的,比如商品列表中的商品卡片、推荐信息流列表等。既然如此,如果我们使用原生的方式撰写好这些组件,并将其内置到小程序 DOM 中(类似 Web Component),也许可以降低某些场景(如长列表)下的性能开销。这种动静结合的方式,可以在不失灵活性的同时,使用原生的方式尽可能的解决渲染性能的问题。
但是,之前的问题又出现了:如何实现组件同构呢?
NO.4
再看同构
回顾一下静态编译的同构方案,不难发现一些特点:
同构的难点在视图层 DSL
各个框架解决同构问题时,几乎都是 Web 优先,使用编译工具向小程序靠拢
众所周知,React 相比小程序要灵活得多。那么,我们是不是可以把思路反过来:小程序优先,在小程序框架的限制内,使用 React 向小程序靠拢。
我们先忽略其他细节,把同构的问题简化一下:
生命周期 & 应用状态管理(data / setData)
视图层 DSL
生命周期 & 应用状态管理
小程序的生命周期和应用状态管理是可以几乎完美对应到 React 的 Class Component 上的。话不多说,上代码:
import React from 'react'
import omit from 'lodash/omit'
import noop from 'lodash/noop'
function createComponent(comp) {
const {
data,
onInit = noop,
deriveDataFromProps = noop,
didMount = noop,
didUpdate = noop,
didUnmount = noop,
methods = {},
render,
} = comp
return class extends React.Component {
constructor(props) {
super(props)
this.state = {
...data,
}
this.setData = this.setState
this.__init()
}
get data() {
return this.state
}
__init() {
for (let key in methods) {
this[key] = methods[key]
}
onInit.call(this, data)
}
componentWillMount() {
deriveDataFromProps.call(this, this.props)
}
componentDidMount() {
didMount.call(this)
}
componentWillReceiveProps(nextProps) {
deriveDataFromProps.call(this, nextProps)
}
componentWillUpdate(nextProps, nextState) {
deriveDataFromProps.call(this, nextProps)
}
componentDidUpdate(prevProps, prevState) {
didUpdate.call(this, prevProps, prevState)
}
componentWillUnmount() {
didUnmount.call(this)
}
render() {
if (render) {
return render.call(this)
}
return null
}
}
}
export default createComponent
有一个问题是,相比 React Web 应用,小程序应用在 app.js 中多出来一个应用启动 / 关闭的生命周期。同时,小程序将「组件」分为了 App、Page 和 Component 三种,这一点和 React 是不太一样的。为了能够尽可能完美还原 App 的生命周期,我尝试利用 window 对象做了一个 bridge,用来动态注册 Page:
import React from 'react'
import ReactDOM from 'react-dom'
import { hot } from 'react-hot-loader'
export class PageRegister {
constructor() {
if (window.__PageRegister) {
return window.__PageRegister
}
this.__page = () => null
this.__handlers = []
window.__PageRegister = this
}
subscribe = (cb) => {
this.__handlers.push(cb)
}
unsubscribe = (cb) => {
this.__handlers = this.__handlers.filter((handler) => handler !== cb)
}
destroy() {
this.__handlers = []
this.__page = function () {
return null
}
}
setPage = (page) => {
this.__page = page
this.__handlers.map((cb) => typeof cb === 'function' && cb(page))
}
getPage = () => this.__page
}
// TODO: 处理 App globalData 和各个生命周期函数
export default function createApp(app) {
const pageRegister = new PageRegister()
class __App extends React.Component {
constructor(props) {
super(props)
this.state = {
page: pageRegister.getPage(),
}
pageRegister.subscribe((page) => this.setState({ page }))
}
componentWillUnmount() {
pageRegister.destroy()
}
render() {
const { page: Page } = this.state
return <Page />
}
}
const App = __DEV__ ? hot(module)(__App) : __App
ReactDOM.render(<App />, document.getElementById('root'))
}
应用初始化时会预埋一个 pageRegister 到 window 上,供页面向 App 中注册自己,调用方式如下:
import React from 'react'
import noop from 'lodash/'
import { PageRegister } from '../createApp'
function createPage(page) {
const pageRegister = new PageRegister()
const { data, onInit = noop, methods, render } = page
class Page extends React.Component {
constructor(props) {
super(props)
this.state = { ...data }
this.setData = this.setState
this.__init()
}
get data() {
return this.state
}
__init() {
for (let key in methods) {
this[key] = methods[key]
}
onInit.call(this, data)
}
render() {
if (render) {
return render.call(this)
}
return null
}
}
pageRegister.setPage(Page)
return Page
}
export default createPage
视图层 DSL
(以下的内容可能有一些投机取巧的成分,但也是思考良久之后写下来的)
在研究并使用了许多视图层同构方案之后,我想抛出一个问题:视图层 DSL 一定要同构么?我认为不一定。
视图层同构的问题是显而易见的:
Web 必须要向小程序妥协,因为小程序不可能支持所有的 HTML Element
同构方案高度依赖静态编译,在 JSX 场景下甚至依赖 AST,这其中的转换是黑盒的,很难保证其中不会出现问题。一旦出现问题,这种静态编译生成的代码非常难 debug (因为我们根本不知道 parser 做了什么)
无论是小程序的 DSL 还是 React 的 render function,其模型都是很清晰的:输入 props 和 state(data),输出结果。在实践中,我发现,即便将小程序的 AXML 和 JSX 分开实现,也不会引入太大的心智负担,反倒会因为没有使用编译工具让整个渲染行为更加可控。
NO.5
总结
Remax 和 Frad 的 Virtual DOM 思路为小程序的同构方案打开了一扇新的大门。它最大的好处在于,整套方案稍加改造即可适配到 React Native 等基于其他视图层实现的渲染框架上,未来具有无限可能。但是,正如文中所说,在对应用性能十分敏感的今天,渲染性能问题是 Remax 等动态解析框架必须要迈过去的坎。随后我也会在这个方向做出更多的尝试。
关于 H5 + 小程序多端构建的部分,涉及到诸如数据绑定、依赖注入、Tree Shaking 等各种问题,我会在随后的分享中慢慢展开。
感谢阅读。
✿ 拓展阅读
回复 “618” 有福利
欢迎关注东半球最大的前端团队
喜欢就点这里